코어자바 5장

예외, 단정, 로깅

예외 처리

(exception handling)을 지원하므로 메서드에서 예외를 던질 수 있다. 연이은 호출 과정에 있는 메서드 중 하나(직접 호출한 쪽일 필요는 없다.)는 해당 예외를 잡아서 처리할 책임이 있다. 예외 처리는 오류를 감지하는 과정과 처리하는 과정을 분리할 수 있다는 장점이 있다.

예외 던지기

필요한 리소스가 없거나 부적합한 매개변수를 받아 메서드가 해야 할 일을 할 수 없는 상황이라면 예외를 던지는 것이 최선이다.

두 경계값 사이에 있는 임의의 정수를 돌려주는 메서드에서 (10, 5)로 호출하여 문제가 생길 경우 예외를 던지는 코드이다.

public static void randInt(int low, int high) {
    if (low > high) {
        throw new IllegalArgumentException(
            String.format("low should be <= high but low is %d and high is %d", low, high);
        )
    }
    return low + (int) (Math.random() * (high - low + 1));
}

throw 문으로 예외 클래스의 객체를 던지고 있으며, 디버깅 메시지를 생성 인수로 전달해 예외 객체를 생성했다. throw 문이 실행되면 정상적인 실행 흐름이 즉시 중단되고, 제어는 예외 핸들러로 전달된다.

예외 계층

모든 예외는 Throwable 클래스의 서브클래스다. Error의 서브클래스는 예외 상황이 일어날 때 던지는 예외다. 예를 들어 메모리 고갈처럼 프로그램에서 처리할 수 없는 상황일 때 던진다. 이런 상황에선 프로그램이 할 수 있는 일은 사용자에게 뭔가 크게 잘못되었다는 메시지를 보여 주는 것 말고는 없다.

프로그래머가 보고하는 예외는 Exception 클래스의 서브 클래스이다. Exception 클래스에는 두가지 서브클래스가 존재한다.

  1. 비검사 예외(unchecked exception)

    • 비검사 예외 RuntimeException의 서브클래스다. RuntimeException의 서브 클래스로 만든 예외는 컴파일 과정에서 검사를 받지 않는다.
    • 실패를 예상하는 상황에서 사용된다. 예를 들어 입출력에 사용하는 파일이 손상되거나 문제가 생기면 많은 예외 클래스가 IOException을 확장하므로 상황에 적잡한 클래스를 사용해 오류를 보고한다.
  2. 다른 예외는 모두 검사 예외(checked exception)이다.

    • 검사를 한다는 의미는 컴파일 시간에 검사를 한다는 말이다. 그러므로 catch 이던 메서드 헤더에 exception signiture로 예외를 선언해야한다.
    • 한 예로 NullPointerException가 있다. 거의 모든 메서드가 이 예외를 던지니 잡을 노력할 필요없다.

구현자가 검사 예외를 던질지, 비검사 예외를 던질지 결정해야 할 때도 있다. Integer.parseInteger(str)은 str이 유효한 정수를 담고 있지 않으면 비검사 예외인 NumberFormatException을 던진다. 그 이유는 Integer.parseInt를 호출하기 전에는 문자열이 유효한 정수인지 검사할 수 있기 때문이다. 또 Class.forName(str)은 str이 유효한 클래스 이름을 담고 있지 않으면 검사 예외인 ClassNotFoundException을 던진다. 실제로 클래스를 로드하기 전에는 그 클래스를 로드할 수 있는지 알 수 없기 때문이다.

자바 API에는 많은 예외 클래스가 있지만, 목적에 맞는 표준 예외 클래스가 없다면 Exception 이나 RuntimeException, 기타 기존 예외 클래스를 확장해서 직접 만들어야 한다. 예외 클래스를 직접 만들 경우 인수 없는 생성자와 메시지 문자열을 받는 생성자를 구현하는 것이 좋다.

public class FileFormatException extends IOException {
    public FileFormatException() {}
    public FileFormatException(String message) {
        super(message);
    }
}

검사 예외 선언

검사 예외를 일으킬 수 있는 메서드는 메서드 헤더의 throws 절에 해당 예외를 선언해야 한다. 메서드에서 throw 문을 사용하기 위해서든, throws 절이 있는 또 다른 메서드를 호출하기 위해서든 해당 메서드가 던질 수 있는 예외를 모두 나열해야 한다.

public void write(Object obj, String filename)
    throws IOException, ReflectiveOperationException

throws 절에서 예외를 공통 슈퍼클래스로 묶을 수 있다. 공통 슈퍼클래스로 묶는 방벙비 좋은지 나쁜지는 던질 예외에 따라 다르다.

  1. 메서드에서 IOException의 여러 클래스를 던질 수 있으면 throws IOException 절로 묶어도 괜찬다.
  2. 하지만 관련 없는 예외를 던질 때는 throws Exception 절로 묶지 말아야한다.(예외 검사의 목적을 잃을 수도 있기 때문이다)

메서드를 오버라이드 할 때 슈퍼클래스 메서드에서 선언한 예외보다 광범위한 검사 예외는 던질 수 없다.(잘 이해가 안됨) 예를 들어 앞에서 나온 write 메서드를 오버라이드 한다고 하자. 그러면 다음과 같이 오버라이드하는 메서드에서 그보다 범위가 좁은 예외만 던질 수 있다. 하지만 관련 없는(관련 없는 기준이 대체 뭐임?) 검사 예외를 던지려고 하면 컴파일에 실패한다.

public void write(Object obj, String filename)
    throws FileNotFoundException

메서드에서 검사 예외나 비검사 예외를 던진다면 자바독의 @throws 태그로 문서화 할 수 있다. 프로그래머 대부분은 문서화할 만한 내용이 있을 때만 이 태그로 문서화 한다.

람다 표현식의 예외 타입은 절대로 명시하지 않는다. 하지만 람다 표현식ㅇ서 검사 예외를 던질 수 있다면 그 예외를 선언한 함수형 인터페이스에만 전달할 수 있다.

list.forEach(obj -> write(obj, "output.dat")); //이 코드는 컴파일 에러가 난다.

public interface Consumer<T> {  //accept 메서드는 어떤 검사 예외도 던지지 않도록 선언되어 있다.
    void accept(T t):
}

예외 잡기

예외를 잡기 위해 try 블록을 사용한다.

try {
    statements
} catch(ExceptionClass ex) {
    handler
}

try 블록에 들어있는 문장(statement)을 실행하다가 주어진 예외 클래스(ExceptionClass)의 예외가 나면 제어 핸들러(handler)로 이동한다. 예외 변수(이 예제에서는 ex)는 예외 객체를 참조하며, 핸들러는 필요하면 해당 예외 객체를 조사할 수 있다.

위의 기본 구조는 두가지로 변경이 가능하다.

서로 다른 예외 클래스에 대응하는 핸들러를 여러개 두는방법 - 위에 있는 예외 클래스 부터 일치하는 예외 타입을 찾고, 없으면 아래로 간다. - 이런 구조에서는 가장 상세한 예외 클래스부터 배치한다.

try{
    statements
} catch(ExceptionClass1 ex){
    handler1
} catch(ExceptionClass2 ex){
    handler2
} catch(ExceptionClass3 ex){
    handler3
}

하나의 catch에 여러개의 예외 클래스를 두는 방법 - 핸들러는 나열된 예외 클래스에 공통으로 있는 메서드만 예외 변수 ex로 호출할 수 있다.

try {
    statements
} catch(ExceptionClass1 ex1|ExceptionClass2 ex2|ExceptionClass3 ex3) {
    handler
}

try-with-resources 문

밑의 코드는 리소스에 접근하여 파일에 쓰기를 수행하고 완료하면 파일을 닫는 코드이다. 하지만 어떤 메서드든 예외를 던지면 out.close()가 호출되지 않는다.

ArrayList<String> lines = ...;
PrintWriter out = new PrintWriter("output.txt");
for (String line: lines) {
    out.println(line.toLowerCase());
}
out.close();

위의 코드는 특별한 try 문으로 해결할 수 있다. 리소스는 반드시 AutoCloseable(close 메서드 하나만 선언되어져 있다.) 인터페이스를 구현하는 클래스에 속해야한다. 예외를 잡는 catch 절을 붙히는 것도 가능하다.

ArrayList<String> lines = ...;
try(PrintWriter out = new PrintWriter("output.txt")){
    for (String line: lines) {
        out.println(line.toLowerCase());
}
}

또는 이전에 선언된 사실상 최종 변수를 헤더에 넣어도 된다.

PrintWriter out = new PrintWriter("output.txt")
try(out){
    for (String line: lines) {
        out.println(line.toLowerCase());
}
}

정상적으로 try 블록의 끝에 이르렀든 예외가 일어났든 간에 try 블록이 끝날 때 리소스 객체의 close 메서드가 호출된다.

여러 리소스를 세미콜콘으로 구분해 선언할 수 있다. 밑의 코드에서 리소스는 초기화 순서의 역순으로 닫는다. 즉 out.close()가 in.close()보다 먼저 호출된다.

try (Scanner in = new Scanner(Paths.get("/sr/share/dic/words"));
PrintWriter out = new PrintWriter("output.txt")) {
        while (in.hashNext())
            out.println(in.next().toLowerCase());
    }
}

PrinterWriter 생성자에서 예외를 던지면, 이 시점에 in은 이미 초기화 되었지만 out은 그렇지 않다. try 문은 이상황을 in.close()를 호출하고 예외를 전파하여 처리한다.

일부 close 메서드는 예외를 던질 수 있는데, try 블록이 정상적으로 끝난 후 이런 메서드에서 예외가 일어나면 호출하는 쪽으로 해당 예외를 던진다. 하지만 또 다른 예외가 일어나서 리소스들의 close 메서드가 호출되고, 그중 하나가 예외를 던지면 그 예외는 원래 일어난 예외보다 덜 중요하기 마련이다. 이런 상황에선 원래 일어난 예외를 다시 던지고, close 호출로 일어난 예외를 잡아서 억누른 예외(suppressed)로 첨부한다. 주 예외(primary exception)를 잡을 때 getSuppressed 메서드를 호출하면 부 예외(secondary exception)를 추출할 수 있다.

try {
    ...
} catch (IOException ex) {
    Throwable[] secondaryException = ex.getSuppressed();
}

finally절

가끔은 AutoCloseable이 아닌 무언가를 정리해야 할 때도 있다. 이대는 finally 절을 사용한다. finally 절은 정상으로든 예외가 일어다서든 try 블록이 끝날 때 실행된다. 이런 패턴은 잠금을 획득 해제 하거나 카운터를 증가 감소 시킬 때, 스택에 무언가를 넣었다가 작업을 마치고 꺼낼 때 사용한다.

try {
    작업수행
}
``` finally {
    정리 작업
}

finally 절에서는 예외를 던지지 말아야한다. try 블록 바디가 예외로 종료되더라도 finally 절에서 일어난 예외로 가려지기 때문이다.

finally 절에 return 문을 작성하면 안된다. try 블록 바디에 return 문이 있어도 finally 절에 있는 return 문이 반환 값을 교체해버린다.

catch 절 뒤에 finally 절을 붙여서 try 문을 구성할 수도 있지만 finally 절에서 in.close()같은 예외를 던지는 코드를 두는 실수를 조심해야한다. 그렇기 때문에 밑의 코드와 같이 try/catch/finally 문을 try-with-resources 문이나 try/catch 문 안에 try/finally를 중첩하는 방법으로 다시 작성하는게 낫다.

        BufferedReader in = null;
        try {
            try {
                in = Files.newBufferedReader(path, StandardCharsets.UTF_8);
            } finally {
                if (in != null) {
                    in.close();
                }
            }
        } catch (IOException e) {
            System.err.println("Caught IOException: "+ e.getMessage());
        }

예외 다시 던지기와 예외 연쇄

예외가 일어날 때 무슨 일을 해야 할지 모르더라도 실패를 로그로 기록하고 싶다면 예외를 다시 던져 적합한 예외 핸들러가 다룰 수 있도록 해야한다.

try{
    작업을 수행한다.
} catch(Exception ex) {
    logger.log(level, message, ex);
    throw ex;
}

아래의 코드는 catch 문에서 Exception 클래스 예외를 잡고 있어서 메서드 예외를 Exception으로 변경해야 할것 같지만, 자바 컴파일러는 실행 흐름을 주의깊게 추적하여 ex가 임의의 Exception이 아니라 try 블록 안에 있는 문장에서 던지는 예외라는 사실을 알아낸다.

public void read(String filename) throws IOException {
    try{
    작업을 수행한다.
    } catch(Exception ex) {
    logger.log(level, message, ex);
    throw ex;
    }
}

서브시스템의 실패를 서브시스템 사용자에게 의미 있는 예외클래스로 보고하고 싶을 때 또는 서블릿 실행중 DB 오류가 일어나 서블릿은 무엇이 잘못되었는지 자세히 알고 싶지 않지만, 서블릿에 문제가 있다는 것은 확실히 알고 싶을 것이다. 이 처럼 던져진 예외의 클래스를 변경하고 싶은 경우 원본 예외를 잡아서 상위 수준 예외로 연쇄해야한다.

try {
    DB에 접근
} catch(SQLException ex) {
    throw new ServletException("database error", ex);
}

ServletException을 잡을 때 예외의 원본을 추출할 수 있다.

Throwable cause = ex.getCause();

ServletException 클래스에는 예외의 원인을 매개변수로 받는 생성자가 있다. 물론 모든 클래스에 있는건 아니다. 이럴땐 initCause 메서드를 호출한다.

try {
    DB에 접근
} catch(SQLException ex) {
    Throwable ex2 = new CruftyOldException("DB error");
    ex2.initCause(ex);
    throw ex2;
}

그렇기 때문에 예외 클래스를 직접 작성하면 다음 생성자도 추가로 작성해줘야한다.

public class FileFormatException extends IOException {
    public FileFormatException(Throwable cause) { initCause(cause);}
    public FIleFormatException(String message, Throwable cause)
}

예외 연쇄 기법은 검사 예외를 허용하지 않는 메서드에서 검사 예외가 일어날 때도 유용하다. 해당 검사 예외를 잡아 비검사 예외에 연쇄하면 된다.(이게 무슨말인지 모르겠다.)

미처리 예외와 스택 추적

예외를 어디에서도 잡지 않으면 스택 추적(stack trace)이 표시된다. 스택 추적은 오류 메시지용 스트림인 System.err로 전달된다.

기술 지원 스태프의 조사용 등으로 예외를 다른 곳에 저장하고 싶다면 기본 미처리 예외 핸들러(uncaught exception)를 설정한다.

Thread.setDefaultUncaughtExceptionHandler((thread, ex) -> {
    예외를 기록한다.
})

미처리 예외는 해당 예외가 일어난 스레드를 종료한다. 지금까지 살펴본 프로그램처럼 어플리케이션에 스레드가 한개만 있다면, 프로그램은 미처리 예외 핸들러를 호출한 후 종료한다.

예외를 잡아야 하는데 무엇을 할지 모르겠다면 스택 추적이라도 출력해야한다.

try {
    Class<?> cl = Class.forName(className);
} catch(ClassNotFoundException ex) {
    ex.printStackTrace();
}

예외의 스택 추적 내용을 저장하고 싶을 땐 문자열에 집어넣으면 된다.

ByteArrayOutputStream out = new ByteArrayOutputStream();
ex.printStackTrace(new PrintWriter(out));
String description = out.toString();

Objects.requireNonNull 메서드

Objects 클래스에는 편리한 매개변수 null 검사용 메서드가 있다.

Objects.requireNonNull(direction);

requireNonNull 호출을 문제의 원인으로 보면 무엇을 실수 했는지 바로 알 수 있고, 또 예외에 대응하는 메시지 문자열도 지정할 수 있다.

this.direction = Objects.requireNonNull(direction, "direction must not be null");

변형을 사용하면 예외를 던지지 않고 대체 값을 전달할 수 있다. 메시지 에는 람다를 넣을 수도 있다.

this.direction = Objects.requireNonNullElse(direction, "North");

Written by@Zero1
This blog is for that I organize what I study and my thinking, feeling and experience.

GitHub